--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit adc4ce26f961559930eb0ee69e0effaf83837841
Parents : a042f02
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-04T16:04:08-05:00
feat(sidebar): implement persistent sidebar collapse state across multiple components and add context menu for image downloads
Changes
8 files changed, 309 insertions(+), 33 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 893fa0be..c65bf56b 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -766,6 +766,7 @@ import ElectronUtils from "../js/ElectronUtils";
import { postRequestPath } from "../js/reticulumPathfinding.js";
import ToneGenerator from "../js/ToneGenerator";
import logoUrl from "../assets/images/logo.png";
+import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
export default {
name: "App",
@@ -941,6 +942,9 @@ export default {
},
deep: true,
},
+ isSidebarCollapsed(collapsed) {
+ saveFeatureSidebarCollapsed("app", collapsed);
+ },
},
beforeUnmount() {
if (typeof this._shellAuthWatchStop === "function") {
@@ -964,6 +968,10 @@ export default {
},
mounted() {
try {
+ const savedSidebarCollapsed = loadFeatureSidebarCollapsed("app");
+ if (savedSidebarCollapsed !== null) {
+ this.isSidebarCollapsed = savedSidebarCollapsed;
+ }
const v = localStorage.getItem("meshchatx_detailed_outbound_send_status");
if (v === "true" || v === "false") {
GlobalState.detailedOutboundSendStatus = v === "true";
diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index 29c95d78..dafcf576 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -34,20 +34,37 @@
v-if="cv.imageGroupSortedChron(entry.items).length === 2"
class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
>
- <button
+ <div
v-for="imgItem in cv.imageGroupSortedChron(entry.items)"
:id="`message-${imgItem.lxmf_message.hash}`"
:key="imgItem.lxmf_message.hash"
- type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ role="button"
+ tabindex="0"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80 group/img cursor-pointer"
@click.stop="
cv.openImage(
cv.lxmfImageUrl(imgItem.lxmf_message.hash),
- cv.imageGroupGalleryUrls(entry.items)
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
+ )
+ "
+ @keydown.enter.prevent.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(imgItem.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
)
"
@contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
>
+ <button
+ type="button"
+ class="absolute top-1 left-1 z-10 p-1 rounded-lg opacity-0 group-hover/img:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.save_image_to_device')"
+ @click.stop="cv.downloadMessageImage(imgItem)"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-4" />
+ </button>
<InViewAnimatedImg
v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
@@ -62,26 +79,43 @@
class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
alt=""
/>
- </button>
+ </div>
</div>
<div
v-else-if="cv.imageGroupSortedChron(entry.items).length === 3"
class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
>
- <button
+ <div
v-for="imgItem in cv.imageGroupSortedChron(entry.items).slice(0, 2)"
:id="`message-${imgItem.lxmf_message.hash}`"
:key="imgItem.lxmf_message.hash"
- type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ role="button"
+ tabindex="0"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80 group/img cursor-pointer"
@click.stop="
cv.openImage(
cv.lxmfImageUrl(imgItem.lxmf_message.hash),
- cv.imageGroupGalleryUrls(entry.items)
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
+ )
+ "
+ @keydown.enter.prevent.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(imgItem.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
)
"
@contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
>
+ <button
+ type="button"
+ class="absolute top-1 left-1 z-10 p-1 rounded-lg opacity-0 group-hover/img:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.save_image_to_device')"
+ @click.stop="cv.downloadMessageImage(imgItem)"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-4" />
+ </button>
<InViewAnimatedImg
v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
@@ -96,21 +130,38 @@
class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
alt=""
/>
- </button>
- <button
+ </div>
+ <div
:id="`message-${cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash}`"
- type="button"
- class="relative col-span-2 aspect-2/1 max-h-52 min-h-[80px] w-full overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ role="button"
+ tabindex="0"
+ class="relative col-span-2 aspect-2/1 max-h-52 min-h-[80px] w-full overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80 group/img cursor-pointer"
@click.stop="
cv.openImage(
cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash),
- cv.imageGroupGalleryUrls(entry.items)
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
+ )
+ "
+ @keydown.enter.prevent.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
)
"
@contextmenu.prevent.stop="
cv.onMessageContextMenu($event, cv.imageGroupSortedChron(entry.items)[2], true)
"
>
+ <button
+ type="button"
+ class="absolute top-1 left-1 z-10 p-1 rounded-lg opacity-0 group-hover/img:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.save_image_to_device')"
+ @click.stop="cv.downloadMessageImage(cv.imageGroupSortedChron(entry.items)[2])"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-4" />
+ </button>
<InViewAnimatedImg
v-if="
isAnimatedRasterType(
@@ -129,20 +180,40 @@
class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
alt=""
/>
- </button>
+ </div>
</div>
<div v-else class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5">
- <button
+ <div
v-for="(cell, idx) in cv.imageGroupSortedChron(entry.items).slice(0, 4)"
:id="`message-${cell.lxmf_message.hash}`"
:key="cell.lxmf_message.hash"
- type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ role="button"
+ tabindex="0"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80 group/img cursor-pointer"
@click.stop="
- cv.openImage(cv.lxmfImageUrl(cell.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
+ cv.openImage(
+ cv.lxmfImageUrl(cell.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
+ )
+ "
+ @keydown.enter.prevent.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(cell.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items),
+ cv.imageGroupSortedChron(entry.items)
+ )
"
@contextmenu.prevent.stop="cv.onMessageContextMenu($event, cell, true)"
>
+ <button
+ type="button"
+ class="absolute top-1 left-1 z-10 p-1 rounded-lg opacity-0 group-hover/img:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.save_image_to_device')"
+ @click.stop="cv.downloadMessageImage(cell)"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-4" />
+ </button>
<InViewAnimatedImg
v-if="isAnimatedRasterType(cell.lxmf_message.fields?.image?.image_type)"
:src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
@@ -163,7 +234,7 @@
>
+{{ cv.imageGroupSortedChron(entry.items).length - 4 }}
</div>
- </button>
+ </div>
</div>
<OutboundTransferProgressFooter
v-if="entry.items[0].is_outbound"
@@ -399,6 +470,14 @@
/>
</template>
<template v-else>
+ <button
+ type="button"
+ class="absolute top-1 left-1 z-10 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.save_image_to_device')"
+ @click.stop="cv.downloadMessageImage(chatItem)"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-4" />
+ </button>
<InViewAnimatedImg
v-if="isAnimatedRasterType(chatItem.lxmf_message.fields?.image?.image_type)"
:src="cv.pendingOutboundImageSrc(chatItem)"
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 3879e6e2..6ed7d7c8 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1309,7 +1309,15 @@
@keydown.right.prevent="imageModalNavigate(1)"
@keydown.escape.prevent="closeImageModal"
>
- <div class="relative max-w-7xl max-h-full" @click.stop>
+ <div class="relative max-w-7xl max-h-full group/image-modal" @click.stop>
+ <button
+ type="button"
+ class="absolute -top-12 left-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors opacity-0 group-hover/image-modal:opacity-100 focus:opacity-100"
+ :title="$t('messages.save_image_to_device')"
+ @click="downloadImageModalCurrent"
+ >
+ <MaterialDesignIcon icon-name="download" class="size-5" />
+ </button>
<button
type="button"
class="absolute -top-12 right-0 inline-flex items-center justify-center w-10 h-10 rounded-xl bg-white/10 dark:bg-zinc-900/10 hover:bg-white/20 dark:hover:bg-zinc-900/20 text-white transition-colors"
@@ -1341,11 +1349,36 @@
>
{{ imageModalIndex + 1 }} / {{ imageModalGallery.length }}
</div>
- <img :src="imageModalUrl" class="max-w-full max-h-[90vh] rounded-xl shadow-2xl" alt="Image preview" />
+ <img
+ :src="imageModalUrl"
+ class="max-w-full max-h-[90vh] rounded-xl shadow-2xl"
+ alt="Image preview"
+ @contextmenu.prevent.stop="onImageModalContextMenu"
+ />
</div>
</div>
</Transition>
+ <Teleport to="body">
+ <ContextMenuPanel
+ v-click-outside="{
+ handler: () => {
+ imageModalContextMenu.show = false;
+ },
+ capture: true,
+ }"
+ :show="imageModalContextMenu.show"
+ :x="imageModalContextMenu.x"
+ :y="imageModalContextMenu.y"
+ panel-class="z-200"
+ >
+ <ContextMenuItem @click="downloadImageModalCurrent">
+ <MaterialDesignIcon icon-name="download" class="size-4 text-blue-500" />
+ {{ $t("messages.save_image_to_device") }}
+ </ContextMenuItem>
+ </ContextMenuPanel>
+ </Teleport>
+
<PaperMessageModal
v-if="isPaperMessageModalOpen"
:message-hash="paperMessageHash"
@@ -1882,7 +1915,14 @@ export default {
expandedMessageInfo: null,
imageModalUrl: null,
imageModalGallery: null,
+ imageModalGalleryChatItems: null,
+ imageModalChatItem: null,
imageModalIndex: 0,
+ imageModalContextMenu: {
+ show: false,
+ x: 0,
+ y: 0,
+ },
isSelectedPeerBlocked: false,
isStrangerPeer: false,
strangerBannerDismissed: false,
@@ -3940,7 +3980,7 @@ export default {
}
const src = this.pendingOutboundImageSrc(chatItem);
if (src) {
- this.openImage(src);
+ this.openImage(src, null, [chatItem]);
}
},
copyableMessagePlainText(chatItem) {
@@ -4270,17 +4310,23 @@ export default {
}
return "0 B";
},
- openImage: async function (url, galleryUrls) {
+ openImage: async function (url, galleryUrls, galleryChatItems = null) {
+ this.imageModalContextMenu.show = false;
if (galleryUrls && galleryUrls.length > 1) {
this.imageModalGallery = galleryUrls.slice();
+ this.imageModalGalleryChatItems = Array.isArray(galleryChatItems) ? galleryChatItems.slice() : null;
+ this.imageModalChatItem = null;
let idx = galleryUrls.indexOf(url);
if (idx < 0) idx = 0;
this.imageModalIndex = idx;
this.imageModalUrl = galleryUrls[idx];
} else {
this.imageModalGallery = null;
+ this.imageModalGalleryChatItems = null;
this.imageModalIndex = 0;
this.imageModalUrl = url;
+ this.imageModalChatItem =
+ Array.isArray(galleryChatItems) && galleryChatItems.length > 0 ? galleryChatItems[0] : null;
}
this.$nextTick(() => {
this.$refs.imageModalOverlay?.focus?.();
@@ -4289,13 +4335,48 @@ export default {
closeImageModal() {
this.imageModalUrl = null;
this.imageModalGallery = null;
+ this.imageModalGalleryChatItems = null;
+ this.imageModalChatItem = null;
this.imageModalIndex = 0;
+ this.imageModalContextMenu.show = false;
+ },
+ imageModalActiveChatItem() {
+ if (this.imageModalGalleryChatItems && this.imageModalGalleryChatItems.length > 0) {
+ return this.imageModalGalleryChatItems[this.imageModalIndex] || null;
+ }
+ return this.imageModalChatItem;
+ },
+ onImageModalContextMenu(event) {
+ if (!this.imageModalActiveChatItem()) {
+ return;
+ }
+ this.imageModalContextMenu.show = true;
+ const menuWidth = 220;
+ const menuHeight = 48;
+ let x = event.clientX;
+ let y = event.clientY;
+ if (x + menuWidth > window.innerWidth) {
+ x = window.innerWidth - menuWidth - 10;
+ }
+ if (y + menuHeight > window.innerHeight) {
+ y = window.innerHeight - menuHeight - 10;
+ }
+ this.imageModalContextMenu.x = x;
+ this.imageModalContextMenu.y = y;
+ },
+ downloadImageModalCurrent() {
+ const chatItem = this.imageModalActiveChatItem();
+ this.imageModalContextMenu.show = false;
+ if (chatItem) {
+ this.downloadMessageImage(chatItem);
+ }
},
imageModalNavigate(delta) {
if (!this.imageModalGallery || this.imageModalGallery.length < 2) return;
const n = this.imageModalGallery.length;
this.imageModalIndex = (this.imageModalIndex + delta + n) % n;
this.imageModalUrl = this.imageModalGallery[this.imageModalIndex];
+ this.imageModalContextMenu.show = false;
},
canMergeImageIntoImageStrip(chatItem) {
const m = chatItem.lxmf_message;
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 4000d637..ad4a4802 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -331,7 +331,7 @@ import {
startCameraStream,
} from "../../js/qrScannerUtils";
import { lxmfConversationListPreview } from "../../js/lxmfConversationPreview";
-import { loadMessagePanes, saveMessagePanes } from "../../js/browserLayoutStore";
+import { loadMessagePanes, saveMessagePanes, loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
@@ -358,7 +358,7 @@ export default {
config: snapshotGlobalConfig(),
hasLoadedConversations: false,
- messagesListSidebarCollapsed: false,
+ messagesListSidebarCollapsed: loadFeatureSidebarCollapsed("messages") ?? false,
peers: {},
panes: [{ id: 1, peer: null }],
@@ -483,6 +483,9 @@ export default {
},
},
watch: {
+ messagesListSidebarCollapsed(collapsed) {
+ saveFeatureSidebarCollapsed("messages", collapsed);
+ },
conversations() {
// update global state
GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 331e37b3..aab1e79a 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -595,6 +595,7 @@ import {
isMicronWasmBundled,
} from "../../js/MicronWasmLoader";
import { VTooltip } from "vuetify/components/VTooltip";
+import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore";
export default {
name: "NomadNetworkPage",
@@ -635,7 +636,7 @@ export default {
nodesListAbortController: null,
nodeDetailAbortController: null,
- nomadNetworkSidebarCollapsed: false,
+ nomadNetworkSidebarCollapsed: loadFeatureSidebarCollapsed("nomadnetwork") ?? false,
nodes: {},
totalNodesCount: 0,
hasMoreNodes: true,
@@ -930,6 +931,9 @@ export default {
},
},
watch: {
+ nomadNetworkSidebarCollapsed(collapsed) {
+ saveFeatureSidebarCollapsed("nomadnetwork", collapsed);
+ },
renderedNodePageHtml(newVal, oldVal) {
if (newVal !== oldVal) {
this.loadedPartialIds = {};
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 564c0247..0164ba0a 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -1104,6 +1104,7 @@ import { countRelayMentions } from "../../js/relayMentionCount.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import { buildRelayMessageTimeline, relayMessageKey } from "../../js/relayMessageTimeline.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
+import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
@@ -1179,7 +1180,7 @@ export default {
selectedHubHash: null,
selectedRoom: null,
expandedHubs: {},
- relaySidebarCollapsed: false,
+ relaySidebarCollapsed: loadFeatureSidebarCollapsed("relayChat") ?? false,
smUp: false,
smMq: null,
showMembers: false,
@@ -1294,7 +1295,7 @@ export default {
},
watch: {
relaySidebarCollapsed(collapsed) {
- localStorage.setItem("relayChatSidebarCollapsed", collapsed ? "1" : "0");
+ saveFeatureSidebarCollapsed("relayChat", collapsed);
this.persistRelayLayout();
},
selectedHubHash() {
@@ -1309,10 +1310,6 @@ export default {
},
mounted() {
WebSocketConnection.on("message", this.onWebsocketMessage);
- const saved = localStorage.getItem("relayChatSidebarCollapsed");
- if (saved === "1") {
- this.relaySidebarCollapsed = true;
- }
this.smMq = window.matchMedia("(min-width: 640px)");
this.smUp = this.smMq.matches;
this.smMq.addEventListener("change", this.onSmMqChange);
diff --git a/meshchatx/src/frontend/js/browserLayoutStore.js b/meshchatx/src/frontend/js/browserLayoutStore.js
index 739e292a..647b451d 100644
--- a/meshchatx/src/frontend/js/browserLayoutStore.js
+++ b/meshchatx/src/frontend/js/browserLayoutStore.js
@@ -2,6 +2,13 @@ const NOMAD_TABS_KEY = "meshchatx.nomadnet.tabs";
const MAP_TABS_KEY = "meshchatx.map.tabs";
const MESSAGE_PANES_KEY = "meshchatx.messages.panes";
const RNSH_LAYOUT_KEY = "meshchatx.rnsh.layout";
+const FEATURE_SIDEBAR_COLLAPSED_KEYS = {
+ app: "meshchatx.sidebar.app",
+ messages: "meshchatx.sidebar.messages",
+ nomadnetwork: "meshchatx.sidebar.nomadnetwork",
+ relayChat: "meshchatx.sidebar.relay-chat",
+};
+const LEGACY_RELAY_SIDEBAR_COLLAPSED_KEY = "relayChatSidebarCollapsed";
/**
* Safely read and parse a JSON value from localStorage.
@@ -128,3 +135,59 @@ export function loadRnshLayout() {
export function saveRnshLayout(state) {
writeJson(RNSH_LAYOUT_KEY, state);
}
+
+/**
+ * Load a persisted feature sidebar collapsed flag.
+ *
+ * @param {"app"|"messages"|"nomadnetwork"|"relayChat"} feature sidebar feature id
+ * @returns {boolean|null} saved value, or null when unset
+ */
+export function loadFeatureSidebarCollapsed(feature) {
+ const key = FEATURE_SIDEBAR_COLLAPSED_KEYS[feature];
+ if (!key) {
+ return null;
+ }
+ const saved = readJson(key);
+ if (saved === true || saved === false) {
+ return saved;
+ }
+ if (feature === "relayChat") {
+ try {
+ if (typeof window !== "undefined" && window.localStorage) {
+ const legacy = window.localStorage.getItem(LEGACY_RELAY_SIDEBAR_COLLAPSED_KEY);
+ if (legacy === "1") {
+ return true;
+ }
+ if (legacy === "0") {
+ return false;
+ }
+ }
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+/**
+ * Persist a feature sidebar collapsed flag.
+ *
+ * @param {"app"|"messages"|"nomadnetwork"|"relayChat"} feature sidebar feature id
+ * @param {boolean} collapsed whether the sidebar is collapsed
+ */
+export function saveFeatureSidebarCollapsed(feature, collapsed) {
+ const key = FEATURE_SIDEBAR_COLLAPSED_KEYS[feature];
+ if (!key) {
+ return;
+ }
+ writeJson(key, collapsed === true);
+ if (feature === "relayChat") {
+ try {
+ if (typeof window !== "undefined" && window.localStorage) {
+ window.localStorage.setItem(LEGACY_RELAY_SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
+ }
+ } catch {
+ // persistence is best-effort
+ }
+ }
+}
diff --git a/tests/frontend/browserLayoutStore.test.js b/tests/frontend/browserLayoutStore.test.js
new file mode 100644
index 00000000..6ff8de4a
--- /dev/null
+++ b/tests/frontend/browserLayoutStore.test.js
@@ -0,0 +1,41 @@
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ loadFeatureSidebarCollapsed,
+ saveFeatureSidebarCollapsed,
+} from "@/js/browserLayoutStore";
+
+describe("browserLayoutStore sidebar collapse", () => {
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("returns null when no sidebar collapse value is stored", () => {
+ expect(loadFeatureSidebarCollapsed("messages")).toBeNull();
+ expect(loadFeatureSidebarCollapsed("app")).toBeNull();
+ expect(loadFeatureSidebarCollapsed("nomadnetwork")).toBeNull();
+ expect(loadFeatureSidebarCollapsed("relayChat")).toBeNull();
+ });
+
+ it("persists and restores sidebar collapse flags per feature", () => {
+ saveFeatureSidebarCollapsed("messages", true);
+ saveFeatureSidebarCollapsed("app", false);
+ saveFeatureSidebarCollapsed("nomadnetwork", true);
+
+ expect(loadFeatureSidebarCollapsed("messages")).toBe(true);
+ expect(loadFeatureSidebarCollapsed("app")).toBe(false);
+ expect(loadFeatureSidebarCollapsed("nomadnetwork")).toBe(true);
+ });
+
+ it("migrates legacy relay sidebar collapse storage", () => {
+ localStorage.setItem("relayChatSidebarCollapsed", "1");
+
+ expect(loadFeatureSidebarCollapsed("relayChat")).toBe(true);
+ });
+
+ it("writes legacy relay sidebar collapse storage for compatibility", () => {
+ saveFeatureSidebarCollapsed("relayChat", false);
+
+ expect(localStorage.getItem("relayChatSidebarCollapsed")).toBe("0");
+ expect(loadFeatureSidebarCollapsed("relayChat")).toBe(false);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────